diff --git a/src/Managing.Application/Bots/TradingBot.cs b/src/Managing.Application/Bots/TradingBot.cs index 454377e..b8353f4 100644 --- a/src/Managing.Application/Bots/TradingBot.cs +++ b/src/Managing.Application/Bots/TradingBot.cs @@ -581,12 +581,28 @@ public class TradingBot : Bot, ITradingBot // ==> Flip the position if (Config.FlipPosition) { - await LogInformation("Try to flip the position because of an opposite direction signal"); - await CloseTrade(previousSignal, openedPosition, openedPosition.Open, lastPrice, true); - await SetPositionStatus(previousSignal.Identifier, PositionStatus.Flipped); - await OpenPosition(signal); - await LogInformation( - $"Position {previousSignal.Identifier} flipped by {signal.Identifier} at {lastPrice}$"); + // Check if current position is in profit before flipping + var isPositionInProfit = await IsPositionInProfit(openedPosition, lastPrice); + + if (isPositionInProfit) + { + await LogInformation("Try to flip the position because of an opposite direction signal and current position is in profit"); + await CloseTrade(previousSignal, openedPosition, openedPosition.Open, lastPrice, true); + await SetPositionStatus(previousSignal.Identifier, PositionStatus.Flipped); + await OpenPosition(signal); + await LogInformation( + $"Position {previousSignal.Identifier} flipped by {signal.Identifier} at {lastPrice}$"); + } + else + { + await LogInformation( + $"Position {previousSignal.Identifier} is not in profit (entry: {openedPosition.Open.Price}, current: {lastPrice}). " + + $"Signal {signal.Identifier} will wait for position to become profitable before flipping."); + + // Keep signal in waiting status to check again on next execution + SetSignalStatus(signal.Identifier, SignalStatus.WaitingForPosition); + return; + } } else { @@ -1167,6 +1183,28 @@ public class TradingBot : Bot, ITradingBot Logger.LogInformation($"Manually opened position {position.Identifier} for signal {signal.Identifier}"); return position; } + + /// + /// Checks if a position is currently in profit based on current market price + /// + /// The position to check + /// The current market price + /// True if position is in profit, false otherwise + private async Task IsPositionInProfit(Position position, decimal currentPrice) + { + if (position.OriginDirection == TradeDirection.Long) + { + return currentPrice >= position.Open.Price; + } + else if (position.OriginDirection == TradeDirection.Short) + { + return currentPrice <= position.Open.Price; + } + else + { + throw new ArgumentException("Invalid position direction"); + } + } } public class TradingBotBackup diff --git a/src/Managing.Domain/Shared/Helpers/TradingBox.cs b/src/Managing.Domain/Shared/Helpers/TradingBox.cs index c9f0d75..f179d2e 100644 --- a/src/Managing.Domain/Shared/Helpers/TradingBox.cs +++ b/src/Managing.Domain/Shared/Helpers/TradingBox.cs @@ -82,7 +82,6 @@ public static class TradingBox } // Ensure limitedCandles is ordered chronologically - loopbackPeriod = 20; var orderedCandles = limitedCandles.OrderBy(c => c.Date).ToList(); var loopback = loopbackPeriod.HasValue && loopbackPeriod > 1 ? loopbackPeriod.Value : 1; var candleLoopback = orderedCandles.TakeLast(loopback).ToList(); diff --git a/src/Managing.WebApp/src/components/organism/ScenarioModal/index.tsx b/src/Managing.WebApp/src/components/organism/ScenarioModal/index.tsx new file mode 100644 index 0000000..83eba07 --- /dev/null +++ b/src/Managing.WebApp/src/components/organism/ScenarioModal/index.tsx @@ -0,0 +1,110 @@ +import React, {useEffect} from 'react' +import {SubmitHandler, useForm} from 'react-hook-form' +import {Modal} from '../../mollecules' +import type {Scenario, Strategy} from '../../../generated/ManagingApi' +import type {IScenarioFormInput} from '../../../global/type' + +interface ScenarioModalProps { + showModal: boolean + onClose: () => void + onSubmit: (data: IScenarioFormInput) => Promise + strategies: Strategy[] + scenario?: Scenario | null // For update mode + isUpdate?: boolean +} + +const ScenarioModal: React.FC = ({ + showModal, + onClose, + onSubmit, + strategies, + scenario = null, + isUpdate = false +}) => { + const { register, handleSubmit, reset, setValue } = useForm() + + // Reset form when modal opens/closes or scenario changes + useEffect(() => { + if (showModal) { + if (isUpdate && scenario) { + // Pre-populate form for update + setValue('name', scenario.name || '') + setValue('loopbackPeriod', scenario.loopbackPeriod || 0) + setValue('strategies', scenario.strategies?.map(s => s.name || '') || []) + } else { + // Reset form for create + reset() + } + } + }, [showModal, isUpdate, scenario, setValue, reset]) + + const handleFormSubmit: SubmitHandler = async (data) => { + onClose() + await onSubmit(data) + } + + const titleHeader = isUpdate ? 'Update Scenario' : 'Scenario Builder' + const submitButtonText = isUpdate ? 'Update' : 'Build' + + return ( + + + + + Name + + + + + + + + + Strategies + + + {strategies.map((item) => ( + + {item.signalType} - {item.name} + + ))} + + + + + + + + Loopback period + + + + + + + + {submitButtonText} + + + + ) +} + +export default ScenarioModal \ No newline at end of file diff --git a/src/Managing.WebApp/src/components/organism/index.tsx b/src/Managing.WebApp/src/components/organism/index.tsx index 99edc8a..f14abd9 100644 --- a/src/Managing.WebApp/src/components/organism/index.tsx +++ b/src/Managing.WebApp/src/components/organism/index.tsx @@ -8,3 +8,4 @@ export { default as SpotLightBadge } from './SpotLightBadge/SpotLightBadge' export { default as StatusBadge } from './StatusBadge/StatusBadge' export { default as PositionsList } from './Positions/PositionList' export { default as WorkflowCanvas } from './Workflow/workflowCanvas' +export { default as ScenarioModal } from './ScenarioModal' diff --git a/src/Managing.WebApp/src/global/type.tsx b/src/Managing.WebApp/src/global/type.tsx index 67863d9..742d8e3 100644 --- a/src/Managing.WebApp/src/global/type.tsx +++ b/src/Managing.WebApp/src/global/type.tsx @@ -19,6 +19,7 @@ import type { Scenario, Signal, StrategiesResultBase, + Strategy, StrategyType, Ticker, Timeframe, @@ -189,6 +190,8 @@ export type IScenarioFormInput = { } export type IScenarioList = { list: Scenario[] + strategies?: Strategy[] + setScenarios?: React.Dispatch> } export type IMoneyManagementList = { diff --git a/src/Managing.WebApp/src/pages/scenarioPage/scenarioList.tsx b/src/Managing.WebApp/src/pages/scenarioPage/scenarioList.tsx index 3bc8ef0..bd5c6e7 100644 --- a/src/Managing.WebApp/src/pages/scenarioPage/scenarioList.tsx +++ b/src/Managing.WebApp/src/pages/scenarioPage/scenarioList.tsx @@ -1,10 +1,9 @@ import React, {useEffect, useState} from 'react' -import type {SubmitHandler} from 'react-hook-form' -import {useForm} from 'react-hook-form' import 'react-toastify/dist/ReactToastify.css' import useApiUrlStore from '../../app/store/apiStore' -import {Modal, Toast} from '../../components/mollecules' +import {Toast} from '../../components/mollecules' +import {ScenarioModal} from '../../components/organism' import type {Scenario, Strategy} from '../../generated/ManagingApi' import {ScenarioClient} from '../../generated/ManagingApi' import type {IScenarioFormInput} from '../../global/type' @@ -15,7 +14,6 @@ const ScenarioList: React.FC = () => { const [strategies, setStrategies] = useState([]) const [scenarios, setScenarios] = useState([]) const [showModal, setShowModal] = useState(false) - const { register, handleSubmit } = useForm() const { apiUrl } = useApiUrlStore() const client = new ScenarioClient({}, apiUrl) @@ -32,8 +30,7 @@ const ScenarioList: React.FC = () => { }) } - const onSubmit: SubmitHandler = async (form) => { - closeModal() + const handleSubmit = async (form: IScenarioFormInput) => { await createScenario(form) } @@ -59,61 +56,14 @@ const ScenarioList: React.FC = () => { Create new scenario - - - - - - Name - - - - - - - - Strategies - - - {strategies.map((item) => ( - - {item.signalType} - {item.name} - - ))} - - - - - - - Loopback period - - - - - - - Build - - - + + ) } diff --git a/src/Managing.WebApp/src/pages/scenarioPage/scenarioTable.tsx b/src/Managing.WebApp/src/pages/scenarioPage/scenarioTable.tsx index 1bb8938..7359282 100644 --- a/src/Managing.WebApp/src/pages/scenarioPage/scenarioTable.tsx +++ b/src/Managing.WebApp/src/pages/scenarioPage/scenarioTable.tsx @@ -1,29 +1,72 @@ -import { TrashIcon } from '@heroicons/react/solid' -import React, { useEffect, useState } from 'react' +import {PencilIcon, TrashIcon} from '@heroicons/react/solid' +import React, {useEffect, useState} from 'react' import useApiUrlStore from '../../app/store/apiStore' -import { Toast, Table } from '../../components/mollecules' -import type { Scenario, Strategy } from '../../generated/ManagingApi' -import { ScenarioClient } from '../../generated/ManagingApi' -import type { IScenarioList } from '../../global/type' +import {Table, Toast} from '../../components/mollecules' +import {ScenarioModal} from '../../components/organism' +import type {Scenario, Strategy} from '../../generated/ManagingApi' +import {ScenarioClient} from '../../generated/ManagingApi' +import type {IScenarioFormInput, IScenarioList} from '../../global/type' -const ScenarioTable: React.FC = ({ list }) => { +const ScenarioTable: React.FC = ({ list, strategies = [], setScenarios }) => { const [rows, setRows] = useState([]) + const [showUpdateModal, setShowUpdateModal] = useState(false) + const [selectedScenario, setSelectedScenario] = useState(null) const { apiUrl } = useApiUrlStore() + const client = new ScenarioClient({}, apiUrl) async function deleteScenario(id: string) { const t = new Toast('Deleting scenario') - const client = new ScenarioClient({}, apiUrl) await client .scenario_DeleteScenario(id) .then(() => { t.update('success', 'Scenario deleted') + // Refetch scenarios after deletion + if (setScenarios) { + client.scenario_GetScenarios().then((scenarios) => { + setScenarios(scenarios) + }) + } }) .catch((err) => { t.update('error', err) }) } + + async function updateScenario(form: IScenarioFormInput) { + const t = new Toast('Updating scenario') + + await client + .scenario_UpdateScenario(form.name, form.loopbackPeriod, form.strategies) + .then(() => { + t.update('success', 'Scenario updated') + // Refetch scenarios after update since the API returns FileResponse + if (setScenarios) { + client.scenario_GetScenarios().then((scenarios) => { + setScenarios(scenarios) + }) + } + }) + .catch((err) => { + t.update('error', err) + }) + } + + function openUpdateModal(scenario: Scenario) { + setSelectedScenario(scenario) + setShowUpdateModal(true) + } + + function closeUpdateModal() { + setShowUpdateModal(false) + setSelectedScenario(null) + } + + const handleUpdateSubmit = async (form: IScenarioFormInput) => { + await updateScenario(form) + } + const columns = React.useMemo( () => [ { @@ -50,18 +93,28 @@ const ScenarioTable: React.FC = ({ list }) => { }, { Cell: ({ cell }: any) => ( - <> + + + openUpdateModal(cell.row.original)} + className="btn btn-ghost btn-sm" + > + + + deleteScenario(cell.row.values.name)} + className="btn btn-ghost btn-sm" > - + - > + ), - Header: '', + Header: 'Actions', accessor: 'id', disableFilters: true, }, @@ -76,6 +129,16 @@ const ScenarioTable: React.FC = ({ list }) => { return ( + + {/* Update Modal */} + ) }