923 lines
32 KiB
Dart
923 lines
32 KiB
Dart
import 'package:equatable/equatable.dart';
|
|
|
|
/// Client-only, derived indicator (not a server field). See [TradeModel.driftStatus].
|
|
///
|
|
/// `exitAlert` was removed on purpose: it used to be driven by a
|
|
/// `hasPendingExitAlert`/`pendingExitReason` pair that no backend DTO ever
|
|
/// produces anymore. Keeping the enum value around would let the UI keep a
|
|
/// dead branch alive that can never be reached from real data (Rules.md §4).
|
|
enum DriftStatus {
|
|
onTrack,
|
|
trailingActive,
|
|
driftWarning,
|
|
}
|
|
|
|
/// Typed counterpart of the backend `SignalDirection` enum
|
|
/// (see `FinlyticCore/Dtos/TechnicalAnalysis/TechnicalEnums.cs`), serialized
|
|
/// as a JSON string via `JsonStringEnumConverter` (unmodified member name,
|
|
/// e.g. `"Buy"`).
|
|
enum SignalDirection {
|
|
buy,
|
|
sell,
|
|
neutral;
|
|
|
|
static SignalDirection fromJson(dynamic value) {
|
|
switch (value) {
|
|
case 'Buy':
|
|
return SignalDirection.buy;
|
|
case 'Sell':
|
|
return SignalDirection.sell;
|
|
case 'Neutral':
|
|
return SignalDirection.neutral;
|
|
default:
|
|
throw FormatException('Unknown SignalDirection from server: $value');
|
|
}
|
|
}
|
|
|
|
bool get isLong => this == SignalDirection.buy;
|
|
|
|
String get label => switch (this) {
|
|
SignalDirection.buy => 'LONG',
|
|
SignalDirection.sell => 'SHORT',
|
|
SignalDirection.neutral => 'NEUTRAL',
|
|
};
|
|
}
|
|
|
|
/// Typed counterpart of the backend `TradeStatus` enum
|
|
/// (see `FinlyticCore/Dtos/Trading/TradeEnums.cs`).
|
|
enum TradeStatus {
|
|
proposed,
|
|
active,
|
|
breakEvenTriggered,
|
|
tp1Hit,
|
|
tp2Hit,
|
|
closed,
|
|
stoppedOut,
|
|
invalidated,
|
|
expired;
|
|
|
|
static TradeStatus fromJson(dynamic value) {
|
|
switch (value) {
|
|
case 'Proposed':
|
|
return TradeStatus.proposed;
|
|
case 'Active':
|
|
return TradeStatus.active;
|
|
case 'BreakEvenTriggered':
|
|
return TradeStatus.breakEvenTriggered;
|
|
case 'Tp1Hit':
|
|
return TradeStatus.tp1Hit;
|
|
case 'Tp2Hit':
|
|
return TradeStatus.tp2Hit;
|
|
case 'Closed':
|
|
return TradeStatus.closed;
|
|
case 'StoppedOut':
|
|
return TradeStatus.stoppedOut;
|
|
case 'Invalidated':
|
|
return TradeStatus.invalidated;
|
|
case 'Expired':
|
|
return TradeStatus.expired;
|
|
default:
|
|
throw FormatException('Unknown TradeStatus from server: $value');
|
|
}
|
|
}
|
|
|
|
String get label => switch (this) {
|
|
TradeStatus.proposed => 'VORSCHLAG',
|
|
TradeStatus.active => 'AKTIV',
|
|
TradeStatus.breakEvenTriggered => 'BREAK-EVEN',
|
|
TradeStatus.tp1Hit => 'TP1 ERREICHT',
|
|
TradeStatus.tp2Hit => 'TP2 ERREICHT',
|
|
TradeStatus.closed => 'GESCHLOSSEN',
|
|
TradeStatus.stoppedOut => 'AUSGESTOPPT',
|
|
TradeStatus.invalidated => 'INVALIDIERT',
|
|
TradeStatus.expired => 'ABGELAUFEN',
|
|
};
|
|
}
|
|
|
|
/// Typed counterpart of the backend `ExecutionMode` enum
|
|
/// (see `FinlyticCore/Dtos/Trading/TradeEnums.cs`).
|
|
enum ExecutionMode {
|
|
signalProposal,
|
|
manualTradeRepublic,
|
|
paperTradingBot;
|
|
|
|
static ExecutionMode fromJson(dynamic value) {
|
|
switch (value) {
|
|
case 'SignalProposal':
|
|
return ExecutionMode.signalProposal;
|
|
case 'ManualTradeRepublic':
|
|
return ExecutionMode.manualTradeRepublic;
|
|
case 'PaperTradingBot':
|
|
return ExecutionMode.paperTradingBot;
|
|
default:
|
|
throw FormatException('Unknown ExecutionMode from server: $value');
|
|
}
|
|
}
|
|
|
|
String get label => switch (this) {
|
|
ExecutionMode.signalProposal => 'Signal (ohne Ausführung)',
|
|
ExecutionMode.manualTradeRepublic => 'Manuell (Trade Republic)',
|
|
ExecutionMode.paperTradingBot => 'Paper-Trading Bot',
|
|
};
|
|
}
|
|
|
|
/// Typed counterpart of the backend `InstrumentCategoryType` enum
|
|
/// (see `FinlyticCore/Dtos/Trading/TradeEnums.cs`).
|
|
enum InstrumentCategoryType {
|
|
stock,
|
|
etf,
|
|
turboLong,
|
|
turboShort,
|
|
factorCertificate;
|
|
|
|
static InstrumentCategoryType fromJson(dynamic value) {
|
|
switch (value) {
|
|
case 'Stock':
|
|
return InstrumentCategoryType.stock;
|
|
case 'Etf':
|
|
return InstrumentCategoryType.etf;
|
|
case 'TurboLong':
|
|
return InstrumentCategoryType.turboLong;
|
|
case 'TurboShort':
|
|
return InstrumentCategoryType.turboShort;
|
|
case 'FactorCertificate':
|
|
return InstrumentCategoryType.factorCertificate;
|
|
default:
|
|
throw FormatException('Unknown InstrumentCategoryType from server: $value');
|
|
}
|
|
}
|
|
|
|
bool get isDerivative => this == turboLong || this == turboShort || this == factorCertificate;
|
|
|
|
String get label => switch (this) {
|
|
InstrumentCategoryType.stock => 'Aktie',
|
|
InstrumentCategoryType.etf => 'ETF',
|
|
InstrumentCategoryType.turboLong => 'Turbo Long',
|
|
InstrumentCategoryType.turboShort => 'Turbo Short',
|
|
InstrumentCategoryType.factorCertificate => 'Faktor-Zertifikat',
|
|
};
|
|
}
|
|
|
|
/// Typed counterpart of the backend `ExitStrategyType` enum
|
|
/// (see `FinlyticCore/Dtos/TechnicalAnalysis/TechnicalEnums.cs`).
|
|
enum ExitStrategyType {
|
|
stagedScaleOutWithBreakEven,
|
|
pureTrailingStop,
|
|
dynamicBandTouch,
|
|
fixedSingleTarget,
|
|
indicatorReversal;
|
|
|
|
static ExitStrategyType fromJson(dynamic value) {
|
|
switch (value) {
|
|
case 'StagedScaleOutWithBreakEven':
|
|
return ExitStrategyType.stagedScaleOutWithBreakEven;
|
|
case 'PureTrailingStop':
|
|
return ExitStrategyType.pureTrailingStop;
|
|
case 'DynamicBandTouch':
|
|
return ExitStrategyType.dynamicBandTouch;
|
|
case 'FixedSingleTarget':
|
|
return ExitStrategyType.fixedSingleTarget;
|
|
case 'IndicatorReversal':
|
|
return ExitStrategyType.indicatorReversal;
|
|
default:
|
|
throw FormatException('Unknown ExitStrategyType from server: $value');
|
|
}
|
|
}
|
|
|
|
String get label => switch (this) {
|
|
ExitStrategyType.stagedScaleOutWithBreakEven => 'Stufenausstieg mit Break-Even',
|
|
ExitStrategyType.pureTrailingStop => 'Reiner Trailing-Stop',
|
|
ExitStrategyType.dynamicBandTouch => 'Dynamische Band-Berührung',
|
|
ExitStrategyType.fixedSingleTarget => 'Fixes Einzelziel',
|
|
ExitStrategyType.indicatorReversal => 'Indikator-Umkehr',
|
|
};
|
|
}
|
|
|
|
/// Typed counterpart of the backend `TrailingStopType` enum
|
|
/// (see `FinlyticCore/Dtos/TechnicalAnalysis/TechnicalEnums.cs`).
|
|
enum TrailingStopType {
|
|
atrMultiplier,
|
|
superTrendLine,
|
|
swingPoints;
|
|
|
|
static TrailingStopType fromJson(dynamic value) {
|
|
switch (value) {
|
|
case 'AtrMultiplier':
|
|
return TrailingStopType.atrMultiplier;
|
|
case 'SuperTrendLine':
|
|
return TrailingStopType.superTrendLine;
|
|
case 'SwingPoints':
|
|
return TrailingStopType.swingPoints;
|
|
default:
|
|
throw FormatException('Unknown TrailingStopType from server: $value');
|
|
}
|
|
}
|
|
|
|
String get label => switch (this) {
|
|
TrailingStopType.atrMultiplier => 'ATR-Multiplikator',
|
|
TrailingStopType.superTrendLine => 'SuperTrend-Linie',
|
|
TrailingStopType.swingPoints => 'Swing-Punkte',
|
|
};
|
|
}
|
|
|
|
double _reqNum(Map<String, dynamic> json, String key) {
|
|
final val = json[key];
|
|
if (val is num) return val.toDouble();
|
|
throw FormatException('Expected numeric field "$key" but got: $val');
|
|
}
|
|
|
|
int _reqInt(Map<String, dynamic> json, String key) {
|
|
final val = json[key];
|
|
if (val is num) return val.toInt();
|
|
throw FormatException('Expected integer field "$key" but got: $val');
|
|
}
|
|
|
|
String _reqStr(Map<String, dynamic> json, String key) {
|
|
final val = json[key];
|
|
if (val is String) return val;
|
|
throw FormatException('Expected string field "$key" but got: $val');
|
|
}
|
|
|
|
/// Typed counterpart of the backend `TakeProfitStage` record
|
|
/// (see `FinlyticCore/Dtos/TechnicalAnalysis/ExitPlanDto.cs`).
|
|
class TakeProfitStageModel extends Equatable {
|
|
final int stageNumber;
|
|
final double targetPrice;
|
|
final double percentToClose;
|
|
final double rMultiple;
|
|
final String description;
|
|
|
|
const TakeProfitStageModel({
|
|
required this.stageNumber,
|
|
required this.targetPrice,
|
|
required this.percentToClose,
|
|
required this.rMultiple,
|
|
required this.description,
|
|
});
|
|
|
|
factory TakeProfitStageModel.fromJson(Map<String, dynamic> json) {
|
|
return TakeProfitStageModel(
|
|
stageNumber: _reqInt(json, 'stageNumber'),
|
|
targetPrice: _reqNum(json, 'targetPrice'),
|
|
percentToClose: _reqNum(json, 'percentToClose'),
|
|
rMultiple: _reqNum(json, 'rMultiple'),
|
|
description: _reqStr(json, 'description'),
|
|
);
|
|
}
|
|
|
|
@override
|
|
List<Object?> get props => [stageNumber, targetPrice, percentToClose, rMultiple, description];
|
|
}
|
|
|
|
/// Typed counterpart of the backend `BreakEvenRule` record.
|
|
class BreakEvenRuleModel extends Equatable {
|
|
final bool enabled;
|
|
final double triggerPrice;
|
|
final double offsetToCoverFees;
|
|
|
|
const BreakEvenRuleModel({
|
|
required this.enabled,
|
|
required this.triggerPrice,
|
|
required this.offsetToCoverFees,
|
|
});
|
|
|
|
factory BreakEvenRuleModel.fromJson(Map<String, dynamic> json) {
|
|
return BreakEvenRuleModel(
|
|
enabled: json['enabled'] == true,
|
|
triggerPrice: _reqNum(json, 'triggerPrice'),
|
|
offsetToCoverFees: _reqNum(json, 'offsetToCoverFees'),
|
|
);
|
|
}
|
|
|
|
@override
|
|
List<Object?> get props => [enabled, triggerPrice, offsetToCoverFees];
|
|
}
|
|
|
|
/// Typed counterpart of the backend `TrailingStopRule` record.
|
|
class TrailingStopRuleModel extends Equatable {
|
|
final TrailingStopType type;
|
|
final double multiplier;
|
|
final double activationPrice;
|
|
final String indicatorKey;
|
|
|
|
const TrailingStopRuleModel({
|
|
required this.type,
|
|
required this.multiplier,
|
|
required this.activationPrice,
|
|
required this.indicatorKey,
|
|
});
|
|
|
|
factory TrailingStopRuleModel.fromJson(Map<String, dynamic> json) {
|
|
return TrailingStopRuleModel(
|
|
type: TrailingStopType.fromJson(json['type']),
|
|
multiplier: _reqNum(json, 'multiplier'),
|
|
activationPrice: _reqNum(json, 'activationPrice'),
|
|
indicatorKey: _reqStr(json, 'indicatorKey'),
|
|
);
|
|
}
|
|
|
|
@override
|
|
List<Object?> get props => [type, multiplier, activationPrice, indicatorKey];
|
|
}
|
|
|
|
/// Typed counterpart of the backend `ReversalCondition` record.
|
|
class ReversalConditionModel extends Equatable {
|
|
final String ruleDescription;
|
|
final String indicatorTrigger;
|
|
|
|
const ReversalConditionModel({
|
|
required this.ruleDescription,
|
|
required this.indicatorTrigger,
|
|
});
|
|
|
|
factory ReversalConditionModel.fromJson(Map<String, dynamic> json) {
|
|
return ReversalConditionModel(
|
|
ruleDescription: _reqStr(json, 'ruleDescription'),
|
|
indicatorTrigger: _reqStr(json, 'indicatorTrigger'),
|
|
);
|
|
}
|
|
|
|
@override
|
|
List<Object?> get props => [ruleDescription, indicatorTrigger];
|
|
}
|
|
|
|
/// Typed counterpart of the backend `ExitPlan` record
|
|
/// (see `FinlyticCore/Dtos/TechnicalAnalysis/ExitPlanDto.cs`).
|
|
///
|
|
/// `takeProfitStages` may legitimately be empty (e.g. [ExitStrategyType.pureTrailingStop]
|
|
/// or [ExitStrategyType.indicatorReversal] manage the exit without discrete price
|
|
/// targets) — callers MUST treat an empty list as "no fixed TP target", not as
|
|
/// missing data to hide behind a fabricated €0.00 (Rules.md §4).
|
|
class ExitPlanModel extends Equatable {
|
|
final ExitStrategyType strategyType;
|
|
final double initialStopLoss;
|
|
final List<TakeProfitStageModel> takeProfitStages;
|
|
final BreakEvenRuleModel? breakEvenRule;
|
|
final TrailingStopRuleModel? trailingStopRule;
|
|
final ReversalConditionModel? reversalCondition;
|
|
final int? maxHoldingBars;
|
|
|
|
const ExitPlanModel({
|
|
required this.strategyType,
|
|
required this.initialStopLoss,
|
|
required this.takeProfitStages,
|
|
this.breakEvenRule,
|
|
this.trailingStopRule,
|
|
this.reversalCondition,
|
|
this.maxHoldingBars,
|
|
});
|
|
|
|
factory ExitPlanModel.fromJson(Map<String, dynamic> json) {
|
|
final rawStages = json['takeProfitStages'];
|
|
final beRaw = json['breakEvenRule'];
|
|
final trailRaw = json['trailingStopRule'];
|
|
final revRaw = json['reversalCondition'];
|
|
|
|
return ExitPlanModel(
|
|
strategyType: ExitStrategyType.fromJson(json['strategyType']),
|
|
initialStopLoss: _reqNum(json, 'initialStopLoss'),
|
|
takeProfitStages: rawStages is List
|
|
? rawStages.map((s) => TakeProfitStageModel.fromJson(Map<String, dynamic>.from(s))).toList()
|
|
: const [],
|
|
breakEvenRule: beRaw is Map ? BreakEvenRuleModel.fromJson(Map<String, dynamic>.from(beRaw)) : null,
|
|
trailingStopRule: trailRaw is Map ? TrailingStopRuleModel.fromJson(Map<String, dynamic>.from(trailRaw)) : null,
|
|
reversalCondition: revRaw is Map ? ReversalConditionModel.fromJson(Map<String, dynamic>.from(revRaw)) : null,
|
|
maxHoldingBars: json['maxHoldingBars'] is num ? (json['maxHoldingBars'] as num).toInt() : null,
|
|
);
|
|
}
|
|
|
|
@override
|
|
List<Object?> get props => [strategyType, initialStopLoss, takeProfitStages, breakEvenRule, trailingStopRule, reversalCondition, maxHoldingBars];
|
|
}
|
|
|
|
/// Typed counterpart of the backend `TradeFillDto`
|
|
/// (see `FinlyticCore/Dtos/Trading/EngineTradeDtos.cs`) — one entry of the
|
|
/// real execution history of a trade (initial entry fill plus any partial
|
|
/// scale-outs).
|
|
class TradeFillModel extends Equatable {
|
|
final String fillId;
|
|
final DateTime executedAtUtc;
|
|
final double price;
|
|
final double quantity;
|
|
final double fee;
|
|
final String? note;
|
|
|
|
const TradeFillModel({
|
|
required this.fillId,
|
|
required this.executedAtUtc,
|
|
required this.price,
|
|
required this.quantity,
|
|
required this.fee,
|
|
this.note,
|
|
});
|
|
|
|
factory TradeFillModel.fromJson(Map<String, dynamic> json) {
|
|
return TradeFillModel(
|
|
fillId: _reqStr(json, 'fillId'),
|
|
executedAtUtc: DateTime.parse(_reqStr(json, 'executedAtUtc')),
|
|
price: _reqNum(json, 'price'),
|
|
quantity: _reqNum(json, 'quantity'),
|
|
fee: _reqNum(json, 'fee'),
|
|
note: json['note'] as String?,
|
|
);
|
|
}
|
|
|
|
@override
|
|
List<Object?> get props => [fillId, executedAtUtc, price, quantity, fee, note];
|
|
}
|
|
|
|
/// Typed counterpart of the backend `ActiveTradeDto`
|
|
/// (see `FinlyticCore/Dtos/Trading/EngineTradeDtos.cs`), delivered by
|
|
/// `GET /api/v1/user/trades`.
|
|
///
|
|
/// Every field below has a direct 1:1 match on `ActiveTradeDto` — no field is
|
|
/// carried over from the old (now-removed) FinlyticAnalyzer-era shape. Fields
|
|
/// that no longer exist server-side (analysisId, isGlobalProposal, userId,
|
|
/// companyName, sector, entryZoneMin/Max, leverageUsed, maxLeverage, hasCfd,
|
|
/// riskTolerance, timeframe, vixValue, vixRegime, winRate, reasoning,
|
|
/// technicalRationale, fundamentalRationale, riskWarning, hourlyUpdates,
|
|
/// closeReason, userExitTimestamp, hasPendingExitAlert, pendingExitReason)
|
|
/// were removed rather than kept alive with a default value, because a
|
|
/// default here would look like a real (but always-empty/zero) measurement
|
|
/// to the UI (Rules.md §4).
|
|
class TradeModel extends Equatable {
|
|
final String id;
|
|
final String proposalId;
|
|
final String underlyingIsin;
|
|
final String symbol;
|
|
|
|
/// Nullable: only set once the engine has bound a concrete Knock-Out/Turbo
|
|
/// product to this trade (see [InstrumentCategoryType.isDerivative]).
|
|
final String? derivativeIsin;
|
|
|
|
/// Nullable: Trade Republic (the only derivative data source in the
|
|
/// system) never provides a WKN for its Knock-Out products, only an ISIN
|
|
/// — this is always `null` today. It must never be back-filled with the
|
|
/// ISIN or an empty string (Rules.md §4).
|
|
final String? derivativeWkn;
|
|
|
|
final ExecutionMode executionMode;
|
|
final InstrumentCategoryType instrumentType;
|
|
final SignalDirection direction;
|
|
final TradeStatus status;
|
|
final double averageBuyIn;
|
|
final double totalQuantity;
|
|
final double initialStopLoss;
|
|
final double currentStopLoss;
|
|
final double currentPrice;
|
|
final double unrealizedPnlEur;
|
|
final double unrealizedPnlPercent;
|
|
final double realizedPnlEur;
|
|
final ExitPlanModel exitPlan;
|
|
final List<TradeFillModel> fills;
|
|
final DateTime openedAtUtc;
|
|
final DateTime? closedAtUtc;
|
|
|
|
const TradeModel({
|
|
required this.id,
|
|
required this.proposalId,
|
|
required this.underlyingIsin,
|
|
required this.symbol,
|
|
this.derivativeIsin,
|
|
this.derivativeWkn,
|
|
required this.executionMode,
|
|
required this.instrumentType,
|
|
required this.direction,
|
|
required this.status,
|
|
required this.averageBuyIn,
|
|
required this.totalQuantity,
|
|
required this.initialStopLoss,
|
|
required this.currentStopLoss,
|
|
required this.currentPrice,
|
|
required this.unrealizedPnlEur,
|
|
required this.unrealizedPnlPercent,
|
|
required this.realizedPnlEur,
|
|
required this.exitPlan,
|
|
required this.fills,
|
|
required this.openedAtUtc,
|
|
this.closedAtUtc,
|
|
});
|
|
|
|
/// A row that was accepted but has not received its first fill yet. Not
|
|
/// currently produced by the engine's acceptance flow (every accepted
|
|
/// proposal starts as [TradeStatus.active] with a real fill), but the
|
|
/// status value exists server-side, so the UI must be able to render it.
|
|
bool get isProposed => status == TradeStatus.proposed;
|
|
|
|
/// A real, currently open position (including break-even/partial-TP
|
|
/// states) — as opposed to a status that never resulted in an ongoing
|
|
/// position ([isRejected]) or one that has been fully resolved ([isClosed]).
|
|
bool get isActive =>
|
|
status == TradeStatus.active || status == TradeStatus.breakEvenTriggered || status == TradeStatus.tp1Hit || status == TradeStatus.tp2Hit;
|
|
|
|
/// Fully resolved with a real (win or loss) outcome on deployed capital.
|
|
bool get isClosed => status == TradeStatus.closed || status == TradeStatus.stoppedOut;
|
|
|
|
/// Never resulted in a filled/ongoing position — the closest server-side
|
|
/// equivalent of the old (removed) "Rejected" status.
|
|
bool get isRejected => status == TradeStatus.invalidated || status == TradeStatus.expired;
|
|
|
|
bool get isDerivative => instrumentType.isDerivative || (derivativeIsin != null && derivativeIsin!.isNotEmpty);
|
|
|
|
/// Derived, client-only display hint — NOT a server field. Unlike the
|
|
/// removed `hasPendingExitAlert`/`hourlyUpdates`-driven version, every
|
|
/// branch here is backed by real server data: a configured trailing-stop
|
|
/// rule, or the server-computed [unrealizedPnlPercent].
|
|
DriftStatus get driftStatus {
|
|
if (exitPlan.trailingStopRule != null && isActive) return DriftStatus.trailingActive;
|
|
if (isActive && unrealizedPnlPercent < -3.5) return DriftStatus.driftWarning;
|
|
return DriftStatus.onTrack;
|
|
}
|
|
|
|
/// The price a closed trade was actually exited at. The engine sets
|
|
/// `CurrentPrice` to the close price at closing time, so this is real data,
|
|
/// not a re-derivation — only meaningful once [isClosed].
|
|
double get actualExitPrice => currentPrice;
|
|
|
|
/// The euro P&L to display "right now": realized once resolved, otherwise
|
|
/// the server's live unrealized figure. Never recomputed client-side.
|
|
double get pnlEur => isClosed ? realizedPnlEur : unrealizedPnlEur;
|
|
|
|
/// First take-profit target price, or `null` if the exit plan manages the
|
|
/// exit without a discrete price target (e.g. pure trailing stop /
|
|
/// indicator reversal). Callers MUST show an explicit "no fixed target"
|
|
/// state instead of a fabricated €0.00 when this is `null` (Rules.md §4).
|
|
double? get primaryTakeProfit => exitPlan.takeProfitStages.isEmpty ? null : exitPlan.takeProfitStages.first.targetPrice;
|
|
|
|
factory TradeModel.fromJson(Map<String, dynamic> json) {
|
|
final derivIsin = json['derivativeIsin'] as String?;
|
|
final derivWkn = json['derivativeWkn'] as String?;
|
|
final rawFills = json['fills'];
|
|
|
|
return TradeModel(
|
|
id: _reqStr(json, 'tradeId'),
|
|
proposalId: _reqStr(json, 'proposalId'),
|
|
underlyingIsin: _reqStr(json, 'underlyingIsin'),
|
|
symbol: _reqStr(json, 'symbol'),
|
|
derivativeIsin: (derivIsin != null && derivIsin.isNotEmpty) ? derivIsin : null,
|
|
derivativeWkn: (derivWkn != null && derivWkn.isNotEmpty) ? derivWkn : null,
|
|
executionMode: ExecutionMode.fromJson(json['executionMode']),
|
|
instrumentType: InstrumentCategoryType.fromJson(json['instrumentType']),
|
|
direction: SignalDirection.fromJson(json['direction']),
|
|
status: TradeStatus.fromJson(json['status']),
|
|
averageBuyIn: _reqNum(json, 'averageBuyIn'),
|
|
totalQuantity: _reqNum(json, 'totalQuantity'),
|
|
initialStopLoss: _reqNum(json, 'initialStopLoss'),
|
|
currentStopLoss: _reqNum(json, 'currentStopLoss'),
|
|
currentPrice: _reqNum(json, 'currentPrice'),
|
|
unrealizedPnlEur: _reqNum(json, 'unrealizedPnlEur'),
|
|
unrealizedPnlPercent: _reqNum(json, 'unrealizedPnlPercent'),
|
|
realizedPnlEur: _reqNum(json, 'realizedPnlEur'),
|
|
exitPlan: ExitPlanModel.fromJson(Map<String, dynamic>.from(json['exitPlan'] as Map)),
|
|
fills: rawFills is List ? rawFills.map((f) => TradeFillModel.fromJson(Map<String, dynamic>.from(f))).toList() : const [],
|
|
openedAtUtc: DateTime.parse(_reqStr(json, 'openedAtUtc')),
|
|
closedAtUtc: json['closedAtUtc'] != null ? DateTime.parse(json['closedAtUtc'] as String) : null,
|
|
);
|
|
}
|
|
|
|
@override
|
|
List<Object?> get props => [
|
|
id,
|
|
proposalId,
|
|
underlyingIsin,
|
|
symbol,
|
|
derivativeIsin,
|
|
derivativeWkn,
|
|
executionMode,
|
|
instrumentType,
|
|
direction,
|
|
status,
|
|
averageBuyIn,
|
|
totalQuantity,
|
|
initialStopLoss,
|
|
currentStopLoss,
|
|
currentPrice,
|
|
unrealizedPnlEur,
|
|
unrealizedPnlPercent,
|
|
realizedPnlEur,
|
|
exitPlan,
|
|
fills,
|
|
openedAtUtc,
|
|
closedAtUtc,
|
|
];
|
|
}
|
|
|
|
/// Typed counterpart of the backend `DerivativeSelectionDto`
|
|
/// (see `FinlyticCore/Dtos/Trading/EngineTradeDtos.cs`).
|
|
class DerivativeSelectionModel extends Equatable {
|
|
final String derivativeIsin;
|
|
|
|
/// Nullable: Trade Republic (the only derivative data source in the system)
|
|
/// does not provide a WKN for its Knock-Out products, only an ISIN. The
|
|
/// backend used to (incorrectly) put the ISIN in this field as a stand-in;
|
|
/// it now sends `null` instead. The UI MUST NOT render an empty string as
|
|
/// if it were a real identifier (Rules.md §4) — treat `null` as "keine
|
|
/// WKN verfügbar" and hide/label the field accordingly.
|
|
final String? derivativeWkn;
|
|
final String issuer;
|
|
final String optionType;
|
|
final double strike;
|
|
final double barrier;
|
|
final double leverage;
|
|
final double safetyBufferPercent;
|
|
final double spreadPercentage;
|
|
final double size;
|
|
|
|
const DerivativeSelectionModel({
|
|
this.derivativeIsin = '',
|
|
this.derivativeWkn,
|
|
this.issuer = '',
|
|
this.optionType = '',
|
|
this.strike = 0.0,
|
|
this.barrier = 0.0,
|
|
this.leverage = 0.0,
|
|
this.safetyBufferPercent = 0.0,
|
|
this.spreadPercentage = 0.0,
|
|
this.size = 0.0,
|
|
});
|
|
|
|
factory DerivativeSelectionModel.fromJson(Map<String, dynamic> json) {
|
|
double parseDbl(dynamic val) {
|
|
if (val == null) return 0.0;
|
|
if (val is num) return val.toDouble();
|
|
return double.tryParse(val.toString()) ?? 0.0;
|
|
}
|
|
|
|
final rawWkn = json['derivativeWkn']?.toString();
|
|
|
|
return DerivativeSelectionModel(
|
|
derivativeIsin: json['derivativeIsin']?.toString() ?? '',
|
|
derivativeWkn: (rawWkn != null && rawWkn.isNotEmpty) ? rawWkn : null,
|
|
issuer: json['issuer']?.toString() ?? '',
|
|
optionType: json['optionType']?.toString() ?? '',
|
|
strike: parseDbl(json['strike']),
|
|
barrier: parseDbl(json['barrier']),
|
|
leverage: parseDbl(json['leverage']),
|
|
safetyBufferPercent: parseDbl(json['safetyBufferPercent']),
|
|
spreadPercentage: parseDbl(json['spreadPercentage']),
|
|
size: parseDbl(json['size']),
|
|
);
|
|
}
|
|
|
|
@override
|
|
List<Object?> get props => [
|
|
derivativeIsin,
|
|
derivativeWkn,
|
|
issuer,
|
|
optionType,
|
|
strike,
|
|
barrier,
|
|
leverage,
|
|
safetyBufferPercent,
|
|
spreadPercentage,
|
|
size,
|
|
];
|
|
}
|
|
|
|
/// Typed counterpart of the backend `ValidationSource` enum
|
|
/// (see `FinlyticCore/Dtos/Trading/EngineTradeDtos.cs`), serialized as a
|
|
/// JSON string (`"Ai"` / `"RuleBased"`) via `JsonStringEnumConverter`.
|
|
enum ValidationSource {
|
|
ai,
|
|
ruleBased,
|
|
;
|
|
|
|
/// Backend default is `Ai` (enum value 0) — a webhook payload that omits
|
|
/// this (new) field entirely must still be interpreted as a real AI result,
|
|
/// matching the server-side default.
|
|
static ValidationSource fromJson(dynamic value) {
|
|
switch (value?.toString()) {
|
|
case 'RuleBased':
|
|
return ValidationSource.ruleBased;
|
|
case 'Ai':
|
|
return ValidationSource.ai;
|
|
default:
|
|
return ValidationSource.ai;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Typed counterpart of the backend `AiValidationResultDto`
|
|
/// (see `FinlyticCore/Dtos/Trading/EngineTradeDtos.cs`).
|
|
///
|
|
/// `confidence` is nullable and MUST stay that way: it is only populated
|
|
/// when [source] is [ValidationSource.ai]. When the engine falls back to a
|
|
/// rule-based approval (e.g. the n8n webhook is unreachable), the server
|
|
/// sends `confidence: null` — inventing a number here (or defaulting to 0.0)
|
|
/// would be exactly the fabricated-metric violation this field was
|
|
/// introduced to eliminate (Rules.md §4).
|
|
class AiValidationResultModel extends Equatable {
|
|
final bool isApproved;
|
|
final double? confidence;
|
|
final ValidationSource source;
|
|
final String thesisSummary;
|
|
final String invalidationReason;
|
|
final List<String> keyCatalysts;
|
|
final List<String> identifiedRisks;
|
|
|
|
const AiValidationResultModel({
|
|
this.isApproved = false,
|
|
this.confidence,
|
|
this.source = ValidationSource.ai,
|
|
this.thesisSummary = '',
|
|
this.invalidationReason = '',
|
|
this.keyCatalysts = const [],
|
|
this.identifiedRisks = const [],
|
|
});
|
|
|
|
bool get isAiValidated => source == ValidationSource.ai;
|
|
bool get hasContent => thesisSummary.trim().isNotEmpty || keyCatalysts.isNotEmpty || identifiedRisks.isNotEmpty;
|
|
|
|
factory AiValidationResultModel.fromJson(Map<String, dynamic> json) {
|
|
return AiValidationResultModel(
|
|
isApproved: json['isApproved'] == true,
|
|
confidence: (json['confidence'] as num?)?.toDouble(),
|
|
source: ValidationSource.fromJson(json['validationSource']),
|
|
thesisSummary: json['thesisSummary']?.toString() ?? '',
|
|
invalidationReason: json['invalidationReason']?.toString() ?? '',
|
|
keyCatalysts: (json['keyCatalysts'] as List<dynamic>?)?.map((e) => e.toString()).toList() ?? const [],
|
|
identifiedRisks: (json['identifiedRisks'] as List<dynamic>?)?.map((e) => e.toString()).toList() ?? const [],
|
|
);
|
|
}
|
|
|
|
@override
|
|
List<Object?> get props => [isApproved, confidence, source, thesisSummary, invalidationReason, keyCatalysts, identifiedRisks];
|
|
}
|
|
|
|
/// Typed counterpart of the backend `TradeProposalDto`
|
|
/// (see `FinlyticCore/Dtos/Trading/EngineTradeDtos.cs`), delivered live over
|
|
/// `SignalRService.tradeProposalStream` (`/hubs/trade-stream`, event `ReceiveTradeProposal`).
|
|
///
|
|
/// `selectedDerivative` and `aiValidation` are intentionally nullable: the
|
|
/// server may legitimately omit them (no suitable Knock-Out product found /
|
|
/// AI validation not yet run), and the UI MUST show an explicit empty state
|
|
/// in that case instead of inventing plausible-looking numbers (Rules.md §4).
|
|
class TradeProposalModel extends Equatable {
|
|
final String proposalId;
|
|
final String underlyingIsin;
|
|
final String symbol;
|
|
final String strategyKey;
|
|
final String direction;
|
|
final double qualityScore;
|
|
final double compositeScore;
|
|
final double currentPrice;
|
|
final double entryPrice;
|
|
final double invalidationPrice;
|
|
final DerivativeSelectionModel? selectedDerivative;
|
|
final AiValidationResultModel? aiValidation;
|
|
final DateTime? createdAtUtc;
|
|
final DateTime? expiresAtUtc;
|
|
|
|
const TradeProposalModel({
|
|
required this.proposalId,
|
|
required this.underlyingIsin,
|
|
required this.symbol,
|
|
required this.strategyKey,
|
|
required this.direction,
|
|
this.qualityScore = 0.0,
|
|
this.compositeScore = 0.0,
|
|
this.currentPrice = 0.0,
|
|
this.entryPrice = 0.0,
|
|
this.invalidationPrice = 0.0,
|
|
this.selectedDerivative,
|
|
this.aiValidation,
|
|
this.createdAtUtc,
|
|
this.expiresAtUtc,
|
|
});
|
|
|
|
bool get isLong => direction.toUpperCase() == 'BUY' || direction.toUpperCase() == 'LONG';
|
|
|
|
factory TradeProposalModel.fromJson(Map<String, dynamic> json) {
|
|
double parseDbl(dynamic val) {
|
|
if (val == null) return 0.0;
|
|
if (val is num) return val.toDouble();
|
|
return double.tryParse(val.toString()) ?? 0.0;
|
|
}
|
|
|
|
final derivRaw = json['selectedDerivative'];
|
|
final aiRaw = json['aiValidation'];
|
|
|
|
return TradeProposalModel(
|
|
proposalId: (json['proposalId'] ?? json['id'])?.toString() ?? '',
|
|
underlyingIsin: json['underlyingIsin']?.toString() ?? '',
|
|
symbol: json['symbol']?.toString() ?? '',
|
|
strategyKey: json['strategyKey']?.toString() ?? '',
|
|
direction: json['direction']?.toString() ?? 'Buy',
|
|
qualityScore: parseDbl(json['qualityScore']),
|
|
compositeScore: parseDbl(json['compositeScore']),
|
|
currentPrice: parseDbl(json['currentPrice']),
|
|
entryPrice: parseDbl(json['entryPrice']),
|
|
invalidationPrice: parseDbl(json['invalidationPrice']),
|
|
selectedDerivative: derivRaw is Map ? DerivativeSelectionModel.fromJson(Map<String, dynamic>.from(derivRaw)) : null,
|
|
aiValidation: aiRaw is Map ? AiValidationResultModel.fromJson(Map<String, dynamic>.from(aiRaw)) : null,
|
|
createdAtUtc: DateTime.tryParse(json['createdAtUtc']?.toString() ?? ''),
|
|
expiresAtUtc: DateTime.tryParse(json['expiresAtUtc']?.toString() ?? ''),
|
|
);
|
|
}
|
|
|
|
@override
|
|
List<Object?> get props => [
|
|
proposalId,
|
|
underlyingIsin,
|
|
symbol,
|
|
strategyKey,
|
|
direction,
|
|
qualityScore,
|
|
compositeScore,
|
|
currentPrice,
|
|
entryPrice,
|
|
invalidationPrice,
|
|
selectedDerivative,
|
|
aiValidation,
|
|
createdAtUtc,
|
|
expiresAtUtc,
|
|
];
|
|
}
|
|
|
|
/// Typed counterpart of the backend `AssetEvaluationResultDto`
|
|
/// (see `FinlyticCore/Dtos/Trading/EngineTradeDtos.cs`), returned by
|
|
/// `POST /api/v1/analyze/manual` and `POST /api/v1/engine/evaluate`.
|
|
///
|
|
/// Unlike the old bare `TradeProposalModel?`/`204` contract, this is always
|
|
/// fully populated — even when [proposal] is `null` (the pipeline ran the
|
|
/// full evaluation but rejected the opportunity, or could not even find a
|
|
/// technical setup for the asset) the real, already-computed scores and AI
|
|
/// reasoning are still present, so the caller never has to show silence for
|
|
/// "no proposal" (Rules.md §4). [daysToNextEarnings] stays nullable because
|
|
/// "unknown/not applicable" and "0 days" are different facts and must not be
|
|
/// collapsed into the same number.
|
|
class AssetEvaluationResultModel extends Equatable {
|
|
final TradeProposalModel? proposal;
|
|
final double compositeScore;
|
|
final double technicalScore;
|
|
final double sentimentScore;
|
|
final double fundamentalScore;
|
|
final bool passedEarningsLockout;
|
|
final int? daysToNextEarnings;
|
|
final bool passedDividendGate;
|
|
final int? daysToNextExDividend;
|
|
final bool aiApproved;
|
|
final String aiThesisSummary;
|
|
final List<String> aiIdentifiedRisks;
|
|
|
|
const AssetEvaluationResultModel({
|
|
this.proposal,
|
|
this.compositeScore = 0.0,
|
|
this.technicalScore = 0.0,
|
|
this.sentimentScore = 0.0,
|
|
this.fundamentalScore = 0.0,
|
|
this.passedEarningsLockout = true,
|
|
this.daysToNextEarnings,
|
|
this.passedDividendGate = true,
|
|
this.daysToNextExDividend,
|
|
this.aiApproved = false,
|
|
this.aiThesisSummary = '',
|
|
this.aiIdentifiedRisks = const [],
|
|
});
|
|
|
|
/// True exactly when the pipeline produced an active trade proposal.
|
|
bool get hasProposal => proposal != null;
|
|
|
|
factory AssetEvaluationResultModel.fromJson(Map<String, dynamic> json) {
|
|
double parseDbl(dynamic val) {
|
|
if (val == null) return 0.0;
|
|
if (val is num) return val.toDouble();
|
|
return double.tryParse(val.toString()) ?? 0.0;
|
|
}
|
|
|
|
final proposalRaw = json['proposal'];
|
|
|
|
return AssetEvaluationResultModel(
|
|
proposal: proposalRaw is Map ? TradeProposalModel.fromJson(Map<String, dynamic>.from(proposalRaw)) : null,
|
|
compositeScore: parseDbl(json['compositeScore']),
|
|
technicalScore: parseDbl(json['technicalScore']),
|
|
sentimentScore: parseDbl(json['sentimentScore']),
|
|
fundamentalScore: parseDbl(json['fundamentalScore']),
|
|
passedEarningsLockout: json['passedEarningsLockout'] == true,
|
|
daysToNextEarnings: json['daysToNextEarnings'] is num ? (json['daysToNextEarnings'] as num).toInt() : null,
|
|
passedDividendGate: json['passedDividendGate'] == true,
|
|
daysToNextExDividend: json['daysToNextExDividend'] is num ? (json['daysToNextExDividend'] as num).toInt() : null,
|
|
aiApproved: json['aiApproved'] == true,
|
|
aiThesisSummary: json['aiThesisSummary']?.toString() ?? '',
|
|
aiIdentifiedRisks: (json['aiIdentifiedRisks'] as List<dynamic>?)?.map((e) => e.toString()).toList() ?? const [],
|
|
);
|
|
}
|
|
|
|
@override
|
|
List<Object?> get props => [
|
|
proposal,
|
|
compositeScore,
|
|
technicalScore,
|
|
sentimentScore,
|
|
fundamentalScore,
|
|
passedEarningsLockout,
|
|
daysToNextEarnings,
|
|
passedDividendGate,
|
|
daysToNextExDividend,
|
|
aiApproved,
|
|
aiThesisSummary,
|
|
aiIdentifiedRisks,
|
|
];
|
|
}
|
|
|